-
Notifications
You must be signed in to change notification settings - Fork 31
Feat/email forwarding #377
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: ref/campaign-processing
Are you sure you want to change the base?
Conversation
📝 WalkthroughWalkthroughAdds message-forwarding end-to-end: new forwarding services, guard/limits, builders, DTOs, repository/manager persistence, admin notifications, forwarded-placeholder handling, DI/config updates, many tests and translation entries. Changes
Sequence Diagram(s)sequenceDiagram
actor User as Forwarding Subscriber
participant MessageForwardService
participant ForwardingGuard
participant MessagePrecacheService
participant ForwardContentService
participant ForwardEmailBuilder
participant ForwardDeliveryService
participant AdminNotifier
participant DB as Database
User->>MessageForwardService: forward(MessageForwardDto)
MessageForwardService->>ForwardingGuard: assertCanForward(uid, campaign, cutoff)
ForwardingGuard->>DB: lookup subscriber / received message / count forwards
DB-->>ForwardingGuard: subscriber/message/counts
ForwardingGuard-->>MessageForwardService: allowed subscriber
loop per recipient
MessageForwardService->>ForwardingGuard: hasAlreadyBeenSent(email, campaign)?
ForwardingGuard->>DB: check forward records
DB-->>ForwardingGuard: exists?
alt not sent
MessageForwardService->>MessagePrecacheService: precache message
MessagePrecacheService-->>MessageForwardService: precached data
MessageForwardService->>ForwardContentService: getContents(campaign, subscriber, email, DTO)
ForwardContentService->>ForwardEmailBuilder: buildForwardEmail(...)
ForwardEmailBuilder-->>ForwardContentService: Email + OutputFormat
ForwardContentService-->>MessageForwardService: Email
MessageForwardService->>ForwardDeliveryService: send(Email)
ForwardDeliveryService->>DB: persist/send record
ForwardDeliveryService-->>MessageForwardService: ack
MessageForwardService->>AdminNotifier: notifyForwardSucceeded(...)
else already sent
MessageForwardService-->>MessageForwardService: skip
end
end
MessageForwardService->>DB: update forwarding subscriber friend count
DB-->>MessageForwardService: ack
MessageForwardService-->>User: complete
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@composer.json`:
- Around line 87-88: The composer.json lists "phpdocumentor/reflection-docblock"
at version ^5.2 but no runtime usage was found; either remove that dependency
from the "require" block or move it to "require-dev" if it is only used for
tooling (e.g., PHPStan or docs), and if you intend to keep it update the version
constraint to the current stable "6.0.1"; modify the composer.json entry
accordingly and run composer update to ensure lockfile consistency.
🧹 Nitpick comments (1)
src/Domain/Common/ExternalImageService.php (1)
150-166: Minor inconsistency:file_get_contentsfallback doesn't respect$verifySsl.The cURL path now respects the
verifySslconfig, but thefile_get_contentsfallback still uses PHP's default SSL behavior. If someone setsverify_ssl=falseexpecting to bypass verification, this fallback path would still verify (or vice versa depending on PHP config).Not a blocker, but worth considering for consistency if both paths should behave the same way.
🔧 Optional: Add SSL context to file_get_contents
private function downloadUsingFileGetContent(string $filename): string { $remoteURLContext = stream_context_create([ 'http' => [ 'method' => 'GET', 'timeout' => $this->externalImageTimeout, 'max_redirects' => '10', - ] + ], + 'ssl' => [ + 'verify_peer' => $this->verifySsl, + 'verify_peer_name' => $this->verifySsl, + ], ]); $cacheFileContent = file_get_contents($filename, false, $remoteURLContext);
215071b to
a736ce2
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php`:
- Around line 32-33: The injected config key name is misleading: change the
Autowire attribute on the CampaignMailContentBuilder constructor parameter
forwardPersonalNote from '%phplist.forward_personal_note_size%' to a
boolean-named key like '%phplist.forward_personal_note_enabled%'; update any
config definitions, fixtures or tests that set forward_personal_note_size to use
forward_personal_note_enabled, and keep the parameter type (?bool
$forwardPersonalNote = false) and its usage in the builder (where it gates
inclusion of the forwarded note) unchanged so the name reflects boolean
semantics.
In `@src/Domain/Messaging/Service/ForwardingStatsService.php`:
- Around line 40-60: Both incrementFriendsCount and updateFriendsCount must
guard against null subscriber IDs to avoid cache collisions for transient
entities: in incrementFriendsCount and updateFriendsCount, after calling
Subscriber::getId() store it in $subscriberId and immediately return if
$subscriberId === null; this prevents comparing null to
$this->friendCountSubscriberId and stops reusing $this->friendCount for
different transient subscribers, preserving correct behavior of the
$this->friendCount cache and related logic.
src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php`:
- Around line 26-28: The early return when $ctx->forwardedBy() === null accesses
$ctx->messagePrecacheDto->textFooter / ->htmlFooter without a null check; update
the condition to also ensure $ctx->messagePrecacheDto is truthy (e.g., change
the if to check both $ctx->forwardedBy() === null && $ctx->messagePrecacheDto)
so the ternary that uses $ctx->messagePrecacheDto->textFooter / ->htmlFooter is
only executed when that DTO exists (look for this check pattern used later in
the file and apply the same guard in FooterValueResolver around the existing
isText() ternary).
In `@src/Domain/Identity/Service/AdminCopyEmailSender.php`:
- Around line 57-62: The mapping over $lists in AdminCopyEmailSender (inside the
$this->sendListAdminCopy branch) calls $list->getOwner()->getEmail() without
guarding for a null owner; change the logic to first filter or skip
SubscriberList instances where $list->getOwner() is null and then map only those
with a non-null owner to their emails, ensuring the resulting $emails is an
array (possibly empty) of strings; update the mapping around $lists and
references to getOwner()/getEmail() accordingly so no direct method call occurs
on a null owner.
🧹 Nitpick comments (3)
src/Domain/Messaging/Service/ForwardDeliveryService.php (1)
39-57: Consider using constants or an enum for status values.The magic strings
'sent'and'failed'are used here and likely referenced elsewhere. Extracting them to constants (or a PHP 8.1+ enum) would improve type safety and make refactoring easier down the road.💡 Example using class constants
class ForwardDeliveryService { + public const STATUS_SENT = 'sent'; + public const STATUS_FAILED = 'failed'; + public function __construct( // ... } public function markSent(Message $campaign, Subscriber $subscriber, string $friendEmail): void { $this->messageForwardManager->create( subscriber: $subscriber, campaign: $campaign, friendEmail: $friendEmail, - status: 'sent' + status: self::STATUS_SENT ); } public function markFailed(Message $campaign, Subscriber $subscriber, string $friendEmail): void { $this->messageForwardManager->create( subscriber: $subscriber, campaign: $campaign, friendEmail: $friendEmail, - status: 'failed' + status: self::STATUS_FAILED ); } }src/Domain/Messaging/Service/ForwardingGuard.php (1)
47-52: Consider extracting status string to a constant.The
'sent'status string appears here and likely in other places. A shared constant (or enum) would prevent typos and make the status values discoverable.Optional improvement
+ private const STATUS_SENT = 'sent'; + public function hasAlreadyBeenSent(string $friendEmail, Message $campaign): bool { $existing = $this->forwardRepository->findByEmailAndMessage($friendEmail, $campaign->getId()); - return $existing !== null && $existing->getStatus() === 'sent'; + return $existing !== null && $existing->getStatus() === self::STATUS_SENT; }src/Domain/Messaging/Service/ForwardContentService.php (1)
40-40: TODO noted - link handling for forwards.Worth tracking this as an issue so it doesn't get lost. Forwarded messages might need special treatment for click tracking or unsubscribe links.
Would you like me to open an issue to track the link handling investigation for forwarded messages?
src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php`:
- Around line 59-66: The test passes the constructor args for MessageForwardDto
in the wrong order: swap the fourth and fifth arguments so they match the
constructor signature (fourth = fromName, fifth = fromEmail); update the
forwardDto instantiation in ForwardContentServiceTest (the new
MessageForwardDto(...) call currently using 'from@example.com' and 'From') to
use 'From' as the fromName and 'from@example.com' as the fromEmail to match
MessageForwardDto::__construct.
🧹 Nitpick comments (8)
tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php (1)
67-113: Consider extracting service instantiation to reduce duplication.The
ForwardDeliveryServiceinstantiation is repeated identically in all four test methods. You could DRY this up with a helper method or by creating the service insetUp(). Not a big deal for a small test class, but it'd make future maintenance a bit easier.♻️ Optional: Extract to setUp or helper
class ForwardDeliveryServiceTest extends TestCase { private MailerInterface&MockObject $mailer; private UserMessageForwardManager&MockObject $forwardManager; + private ForwardDeliveryService $service; protected function setUp(): void { $this->mailer = $this->createMock(MailerInterface::class); $this->forwardManager = $this->createMock(UserMessageForwardManager::class); + $this->service = new ForwardDeliveryService( + mailer: $this->mailer, + messageForwardManager: $this->forwardManager, + bounceEmail: 'bounce@example.test', + ); } public function testSendUsesBounceEnvelopeAndRecipient(): void { - $service = new ForwardDeliveryService( - mailer: $this->mailer, - messageForwardManager: $this->forwardManager, - bounceEmail: 'bounce@example.test', - ); - $email = (new Email())->to('friend@example.test'); // ... rest of test using $this->servicesrc/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php (1)
174-187: The* 2multiplier on line 181 is unclear.The condition
($this->forwardPersonalNoteSize * 2) > strlen($note)feels like magic. Why multiply by 2? A comment referencing the ticket (0011996) exists, but the logic itself could use a brief inline explanation for future maintainers.Also,
strlen($note)counts bytes, not characters. For multi-byte (UTF-8) notes, you might wantmb_strlen($note, 'UTF-8')if the intent is to limit by character count rather than byte length.💡 Suggested clarification
$escapedNote = htmlspecialchars($note, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); - //0011996: forward to friend - personal message - if (($this->forwardPersonalNoteSize * 2) > strlen($note)) { + // 0011996: forward to friend - personal message + // Allow notes up to twice the configured max size (legacy behavior). + $maxAllowedLength = $this->forwardPersonalNoteSize * 2; + if ($maxAllowedLength > mb_strlen($note, 'UTF-8')) { $htmlMessage = nl2br($escapedNote) . '<br/>' . $htmlMessage; $textMessage = $note . "\n" . $textMessage; }tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php (1)
105-139: Good coverage forhasAlreadyBeenSentscenarios.Both the true case (existing forward with 'sent' status) and false case (no existing forward) are tested. The mock setup correctly verifies the interaction with
findByEmailAndMessage.One small note: you might also want a test case for when a forward exists but with a non-'sent' status (e.g., 'failed') to ensure that returns
false. Currently only the "none exists" case is tested for the false path.tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php (1)
190-194: ThelogicalOrconstraint may not fully validate both formats are used.The constraint
self::logicalOr(OutputFormat::Html, OutputFormat::Text)verifies that each call receives either Html or Text, but doesn't guarantee that both formats are actually used across the two calls. If both calls happened to useOutputFormat::Html, this test would still pass.If you want stricter verification that both formats are used exactly once, consider using
withConsecutive(deprecated in PHPUnit 10+) or capturing the arguments for manual assertion.That said, this is a minor point since the mock setup at lines 182-184 already controls which format is returned for each call, so the test is still meaningful.
tests/Unit/Domain/Messaging/Service/Builder/HttpReceivedStampBuilderTest.php (1)
55-72: Keep this test hermetic—avoid real reverse DNS lookups.
Line 59 relies ongethostbyaddr()behavior for a TEST‑NET IP. That can still hit resolvers and be flaky/slow in CI. Consider stubbing DNS resolution or injecting a resolver so you can deterministically force the “no reverse DNS” path.💡 Example: stub gethostbyaddr in the builder namespace (test-only)
<?php declare(strict_types=1); namespace PhpList\Core\Domain\Messaging\Service\Builder { function gethostbyaddr(string $ip): string|false { return false; // force fallback to IP-only } } namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder;tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php (1)
82-85: Mock expectation set after invocation won't work as intended.The
expects(self::once())on line 70 already ensures only one call happens throughout the test, so the test does verify the reset behavior. However, settingexpects(self::never())on line 83 after the invocation on line 80 is misleading - PHPUnit expectations should be configured before the calls occur.The test passes for the right reasons (the
once()constraint), but this code block suggests a verification that isn't actually happening. Consider removing lines 82-85 or restructuring for clarity.🧹 Suggested cleanup
$service->incrementFriendsCount($subscriber); $service->incrementFriendsCount($subscriber); $service->updateFriendsCount($subscriber); - // Second update attempt should be a no-op due to cache reset - $this->attrManager->expects(self::never())->method('createOrUpdateByName'); + // Second update is a no-op (cache was reset) - verified by the once() expectation above $service->updateFriendsCount($subscriber); - self::assertTrue(true); }tests/Unit/Domain/Identity/Service/AdminNotifierTest.php (1)
51-68: SamewithConsecutivedeprecation note applies here.Just like in the other test file,
withConsecutive()is deprecated in PHPUnit 10+. Consider refactoring to use a callback with invocation tracking if you upgrade.tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php (1)
113-119:withConsecutivewill break in PHPUnit 10+ (already deprecated since 9.6).Heads up —
withConsecutive()was deprecated in PHPUnit 9.6 and fully removed in PHPUnit 10. The project is currently on 9.5, so it's fine now, but if you upgrade PHPUnit to 10 or later, this will stop working. When you get there, refactor to use a callback with invocation tracking (similar to your existingsendmethod expectations).No urgency, but worth flagging for a future PHPUnit upgrade.
Summary by CodeRabbit
New Features
Configuration
Internationalization
Thanks for contributing to phpList!